Skip to content

feat!: per-account reconcile report and fleet reconcile for reporting subscriptions - #1802

Merged
bokelley merged 2 commits into
nextfrom
claude/reporting-reconcile-accounts
Nov 2, 2026
Merged

bokelley merged 2 commits into
nextfrom
claude/reporting-reconcile-accounts

Conversation

@bokelley

@bokelley bokelley commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Summary

TS parity for the buyer reconcile() report (decisions 1 and 2 of the reporting-subscriptions DX follow-ups).

Per-account report. ReconcileReport.accounts now holds one ReconcileAccount per account, the shape of TS ReconcileAccount (adcp-client#3576, #3581):

ReconcileAccount(
    account_id="acc_a",
    subscription_ids=("analytics-hourly",),
    status="reconciled" | "unauthorized" | "failed",
    unauthorized_at="2026-11-01T02:05:00Z",   # unauthorized only
    withheld_period_count=3,                  # unauthorized only, the seller's count
    error=ReconcileAccountError(task="get_reporting_status", code="TASK_FAILED", message=...),  # failed only
)
  • failed records the first read that failed for the account: get_media_buys, get_reporting_status, or coverage (a CoverageInputError, with its code). Paging that does not finish is LIMIT_EXCEEDED.
  • A principal or roster failure is the entry account_id="*", as in TS.
  • to_dict() serves the objects under accounts. The report also carries agent_id and since, as TS does.
  • Old shape: report.account_ids is the tuple of ids accounts used to be. It leaves out "*". The events module now reads it.

Fleet fan-out. ADCPMultiAgentClient.reporting is a FleetReportingClient:

fleet = await multi.reporting.reconcile(since="2026-11-01", concurrency=4, agent_ids=None)
fleet.by_seller   # agent_id -> ReconcileReport
fleet.issues      # FleetReconcileIssue(agent_id, code, message, ...)
fleet.definitive  # every seller definitive and none failed
fleet.summary     # summed ReconcileSummary
  • Each seller is read through its own multi.agent(id).reporting, so the per-seller cache of last-read facts carries across runs.
  • The report key is issues, as in TS's fleet report after #3581.
  • A seller whose principal could not be read, or whose run raised, becomes a SUBSCRIPTION_PRINCIPAL_UNAVAILABLE fleet issue with its agent_id. It is left out of by_seller and the summary. In TS, that read throws inside the fleet. Python's single-seller reconcile() turns it into a report issue instead, so the fleet lifts it.
  • One difference from TS: an unknown agent_ids entry is refused before dispatch (ReportingSubscriptionError, INVALID_REQUEST, dispatched=False). TS filters it out silently. So is concurrency < 1, as in TS.

Outcome names (decision 3). No change needed: Python's accounts.change outcomes already use kind="separate_account" and kind="matched_existing" (AccountSeparate, AccountMatched), the names TS #3587 adopted.

Docs: the buyer guide gains the account statuses and an Across sellers section. The errors guide covers failed entries and fleet issues, and the README shows the fleet call.

Test plan

  • tests/test_reporting_subscription_reconcile.py:
    • reconciled entry and to_dict;
    • "*" on roster and principal failures;
    • a ledger failure is failed with get_reporting_status;
    • a lost account is unauthorized with unauthorized_at, withheld_period_count and subscription_ids;
    • fleet: two healthy sellers and one with no principal, the summed summary, agent_ids, refusals, and multi.reporting delegating to each agent's reporting.
  • Events, receipts, TS-parity, public API and export-surface suites.
  • ruff check, ruff format --check, mypy on the changed modules.

BREAKING CHANGE: ReconcileReport.accounts is a tuple of ReconcileAccount instead of account-id strings, and to_dict() serves objects under accounts. Read report.account_ids for the ids.

🤖 Generated with Claude Code

… subscriptions

ReconcileReport.accounts now holds one ReconcileAccount per account, the
shape the TS SDK reports: account_id, subscription_ids, a status of
reconciled, unauthorized or failed, unauthorized_at and
withheld_period_count for a lost account, and an error naming the task,
code and message of the read that failed. A principal or roster failure
is the entry "*". report.account_ids is the old tuple of ids. The report
also carries agent_id and since.

ADCPMultiAgentClient.reporting.reconcile fans reconcile out across
sellers with bounded concurrency and returns a FleetReconcileReport:
by_seller, issues, definitive and the summed summary, matching TS
multiClient.reporting.reconcile. A seller whose principal could not be
read is a fleet issue with its agent_id.

BREAKING CHANGE: ReconcileReport.accounts is a tuple of ReconcileAccount
instead of account-id strings, and to_dict serves objects under
accounts. Read report.account_ids for the ids.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…cile

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve.

Clean PR with no Critical/High/Medium findings. The breaking ReconcileReport.accounts type change correctly carries the semver signal (feat! with BREAKING CHANGE footer and a report.account_ids migration note), averting the minor-cut risk where release-please would otherwise ship a break under a non-breaking prefix. Consumers of .accounts (events.py:247,605) both switched to the new account_ids property — a load-bearing switch, since the "*" roster/principal entry now lives in accounts and would otherwise break _merge_partial's "everything" path. The reconcile.py entry-append gate (L480) and _account_entry precedence (L505-521) check out, with a test added in test_reporting_subscription_reconcile.py.

One non-blocking follow-up noted by the reviewer: reconcile_fleet (reconcile.py:574) fans out via asyncio.gather + semaphore with no per-seller wait_for timeout — a hung seller holds a concurrency slot. This inherits single-reconcile timeout behavior so it is not a regression; a bounded per-seller timeout would be a nice improvement but is not required here.

Decision table: no critical/high findings (row 1 no), gated_paths false (row 2 no), high_risk false (rows 3/5 no), no medium findings (rows 4/8 no), no prior escalation (row 6 no), no team gate (row 7 no) → row 9 approve.

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — clean subsequent review, no findings.

This PR adds reporting subscription reconciliation support (facade, events, reconcile logic, docs, tests). The delta since the prior approve is a single guard at reconcile.py:980: with subscription_id= set, an account covered only by other subscriptions is no longer listed in report.accounts, plus a test asserting accounts == () and periods == () for a non-matching subscription.

Verification notes from the reviewer, confirmed:

  • Lines for an account are only produced after subscription_ids.append(sub_id), so no account with periods is dropped.
  • Unauthorized accounts covered by the requested subscription clear the loop via accounts_unauthorized and keep their sub_id.
  • The subscription_id is None path remains unconditional, matching prior behavior.

Gate posture: gated_paths: false, review_decision: APPROVED, high_risk: false, no no-auto-approve team match. No critical/high/medium findings. None of decision-table rows 1–8 fire, so the outcome falls through to row 9 — approve.

@bokelley
bokelley merged commit 33a8bb0 into next Nov 2, 2026
41 checks passed
@bokelley
bokelley deleted the claude/reporting-reconcile-accounts branch November 2, 2026 04:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant